Tema 02 - Limpieza y Tratamiento de Datos

De datos en bruto a información útil para decidir con tidyverse

Pedro Albarrán

Dpto. de Fundamentos del Análisis Económico. Universidad de Alicante

Alberto Pérez

Introducción. Datos Ordenados

Limpieza y “doma” de datos

  • El ciclo de vida de los datos

  • El desafío real del análisis de datos:

    • 80% del tiempo de trabajo “sucio” : limpieza y preparación

    • 20% del tiempo: análisis y modelización

  • tidyverse incluye una colección de bibliotecas con herramientes eficientes para el proceso de “tratamiento de datos” (“data wrangling”)

  • El objetivo es obtener un conjunto de datos ordenado y limpio para realizar el análisis eficientemente y obtener información útil para la toma de decisiones

Datos ordenados (‘tidy data’): Principios fundamentales

1.- Cada columna es una variable: mide el mismo atributo entre unidades

2.- Cada fila es una observación (caso): misma unidad a través de atributos

3.- Cada celda es un valor

  • Tenemos información similar y no redundante en una misma tabla

  • Es una forma natural (variable = vector columna) para trabajar con datos

  • tidyverse es eficiente con datos ordenados

Datos no ordenados

  • Otras estructuras como esta pueden tener sentido para mostrar información (o por convenciones)

  • La visualización es atractiva, PERO sobran filas para analizar los datos: ej., total de personas con hijos y sin pareja entre 30 y 39 años

Caso de Estudio: RetailCorp

  • Contexto: Cadena de venta al por menor con 12 tiendas en España

  • Objetivo: Analizar rendimiento de ventas para toma de decisiones estratégicas

load("data/retail_data.RData")
  • Recibe datos de

    • Sistema de ventas (POS)

    • CRM de clientes

    • Inventario de productos

    • RRHH de empleados

    • Informes Excel de gerentes

Problemas Típicos:

  • Formatos inconsistentes

  • Datos duplicados

  • Valores ausentes

  • Estructuras inadecuadas

  • Tablas dispersas

Transformación de datos (una tabla)

Funciones de transformación de datos

  • La mayoría de operaciones pueden realizarse combinando 5 “verbos”:

    1. select(): selecciona columnas (variables)

    2. filter(): filtra (extraer) filas

    3. mutate(): crea nuevas columnas

    4. arrange(): ordena filas

    5. summarize(): crea resúmenes de la tabla

    • Más la tubería %>% o |>

    • y group_by()

  • NOTA: existe una colección de “chuletas” de R, p.e., para transformación.

  • Todos tienen como primer argumento un data frame, los siguientes describen qué hacer (con columnas o filas) y devuelven otro data frame

1. select()

  • Selecciona variables por nombres o posiciones de columnas, separados por comas
  • Ej., un analista solo necesita información básica de ventas
select(ventas, id_venta, fecha, id_tienda, id_producto, total)

select(ventas, 1:2, 5, 4, 12)
  • Aplicación Empresarial: el equipo de marketing solo necesita información de cliente y venta
ventas_mkt <- select(ventas, 
                fecha, id_cliente, id_producto, total)

2. filter()

  • Conserva filas en las que una condición lógica (o varias separadas por comas) es verdadera
  • Caso de Uso: Gerente quiere analizar ventas específicas con determinadas características
ventas_top      <- filter(ventas, total > 100)

ventas_ene_2024 <- filter(ventas, año == 2024, mes == 1)

ventas_mad_bcn  <- filter(ventas, id_tienda %in% c(1, 2))

ventas_premium  <- filter(ventas, 
                     total > 100 & descuento_porcentaje == 0)

Encadenando operaciones con tuberías: %>% o |>

  • Las operaciones encadenadas no son legibles o crean objetos intermedios
ventas_top_markt <- select(filter(ventas, total > 100),
                      fecha, id_cliente, id_producto, total)

ventas_top        <- filter(ventas, total > 100)
ventas_top_markt  <- select(ventas_top, 
                       fecha, id_cliente, id_producto, total)
  • datos %>% filter(condicion) equivale a filter(datos, condicion)

  • El anidamiento con tuberías sigue el flujo natural de lectura

    • Toma una tabla y pásala a un comando que acepta y produce un data frame
    • Toma la nueva tabla resultante y pásala a otro comando
ventas_top_markt <- ventas |>                   
                     filter(total > 100) |>     
                     select(fecha, id_cliente, id_producto, total)

Funciones auxiliares de selección (de columnas)

# Por rango de columnas
ventas |> select(id_venta:id_tienda)

# Excluir columnas
ventas |> select(-descuento_porcentaje, -descuento_aplicado)

# Por patrón de nombre
ventas |> select(starts_with("id_"))
ventas |> select(ends_with("_porcentaje"))
ventas |> select(contains("descuento"))

# Por tipo de dato
ventas |> select(where(is.numeric))
ventas |> select(where(is.character))
  • pull(): extrae una única columna, como vector
ventas |> pull(cantidad) |> mean()

3. mutate()

  • Crea o modifica variables mediante una fórmula a partir de otras columnas

ventas2 <- ventas |>
  mutate(
    precio_final_unitario = total / cantidad,
    es_inicio_mes         = day(fecha) <= 7
  )
  • Funciones para operar con fechas (usando lubridate)
ventas_tiempo <- ventas |>
  mutate(
    fecha_completa = as.Date(fecha),  # tipo de objeto "fecha"
    semana_año = week(fecha),
    nombre_mes = month(fecha, label = TRUE),
    dias_desde_venta = as.numeric(Sys.Date() - fecha)
  )

4. arrange()

  • re-ordena las filas todas las columnas de un data frame
    • en orden ascendente (por defecto) o descendente con desc()

  • Caso de Uso: Top 10 ventas más altas
ventas |> 
  arrange(desc(total)) |> 
  select(id_venta, fecha, total) |> head(10) 
  • Ordenamientos múltiples: ordena por la primera variable y luego, en caso de empate, por la siguiente, etc.
ventas |>
  arrange(id_tienda, desc(total)) |>
  select(id_tienda, id_venta, total) |> head(10)

5. summarize()

  • Crea un nuevo conjunto de datos de una sola fila, con variables nuevas de un solo valor que resumen los datos completos

  • Caso de Uso: KPIs para el dashboard ejecutivo
ventas |>
  summarize(
    total_ventas       = n(),           # Volumen (núm. de filas)
    ingresos_totales   = sum(total),    # Ingresos
    ingresos_promedio  = mean(total),
    ingresos_mediano   = median(total),
    descuento_promedio = mean(descuento_porcentaje), # Descuentos
    descuento_total    = sum(descuento_aplicado),
    unidades_vendidas  = sum(cantidad),             # Productos
    clientes_unicos    = n_distinct(id_cliente)     # Clientes 
                         # (núm. de filas distintas)
  )

group_by(): Análisis por Grupos

  • Cambia el alcance de cada función para que no actúe sobre todo el data frame sino en grupos individuales
  • group_by() + summarize() = el poder de la agregación

    • Concepto: cambiar el nivel de análisis: de transacciones a tiendas, productos, etc.)

    • En Excel: Tablas dinámicas, AGRUPARPOR() (y SUMAR.SI/SUMIF)

ventas                             # tabla a nivel de transacción

ventas |>
  summarize(ingresos = sum(total)) # resumen global

ventas |>
  group_by(id_tienda) |>
  summarize(ingresos = sum(total)) # resumen por tienda
                                   # tabla a nivel de tienda

Combinando mutate() con group_by()

  • Diferencia Clave:

    • group_by() + summarize(): Reduce filas (nuevo dataset agregado a nivel de los grupos)

    • group_by() + mutate(): Mantiene filas (añade columnas calculadas por grupo a nivel de la tabla original)

  • Ejemplo: Porcentaje de las ventas mensuales que representa cada transacción

ventas |>
  group_by(id_tienda, mes) |>
  mutate(
    ventas_tienda_mes = sum(total),
    pct_de_ventas_mes = total / ventas_tienda_mes * 100
  ) |>
  slice_max(pct_de_ventas_mes) |> 
  select(id_tienda, mes, id_venta, total, pct_de_ventas_mes)

Análisis multinivel y ungroup()

  • Encontrar la fecha con la mayor venta por tienda
ventas |>
  group_by(id_tienda) |>
  arrange(desc(total)) |>  
  slice(1) |>                     # Primera fila (de cada grupo)
  select(id_tienda, fecha, total)
# alternativa: slice_max()
  • IMPORTANTE: No olvidar ungroup() o .groups = "drop" después de terminar operaciones agrupadas

  • Cálculo de Porcentajes Globales: sin desagrupar, sum(total) suma por tienda → siempre da 100%

ventas |>
  group_by(id_tienda) |>
  mutate(ventas_tienda = sum(total)) |>
  ungroup() |>
  mutate(porcentaje = ventas_tienda / sum(total) * 100)

Análisis multinivel y ungroup() (cont.)

  • Filtrar Top Global: sin desagrupar, será top 5 de cada tienda
ventas |>
  group_by(id_tienda) |>
  mutate(ventas_tienda = sum(total), 
         .groups = "drop" ) |>
  arrange(desc(ventas_tienda)) |>
  slice_head(n = 5)
  • Media Global después de agrupar: sin desagrupar, 1 fila por tienda (media mensual de cada tienda)
# Ventas por tienda y mes
ventas_mes_por_tienda <- ventas |>
  group_by(id_tienda, mes) |>
  summarize(total_mes = sum(total))

ventas_mensuales_medias <- ventas_mes_por_tienda |>
  ungroup() |>
  summarize(media = mean(total_mes))
# Resultado: 1 fila (media de todos los meses de todas las tiendas)

Funciones auxiliares para filas

  • Extraer filas pero NO por condición: por posición (slice(), slice_head()), aleatoriamente (slice_sample()), etc.
ventas |> slice_max(total, n = 5) # Top 5 ventas
ventas |> slice_sample(n = 100)   # sub-muestra aleatoria
  • distinct(): extrae sólo las filas únicas (una o varias variables)
ventas %>% distinct(id_producto)
  • drop_na() y replace_na(): elimina/reemplaza filas con valores ausentes
# Quita filas con NA en cualquier variable
ventas_completas <- ventas %>%
  drop_na()                    

# solo quita si precio_unitario es NA
ventas_completas_precio <- ventas %>%
  drop_na(precio_unitario)  

Otras funciones auxilidares de tidyverse

  • Muchas funciones son equivalentes a otras de R base:

    • parse_number(), parse_factor(), etc. por as.numeric(), as.factor(), etc.

    • bind_cols() y bind_rows() por cbind() y rbind()

    • if_else() y case_when() para ejecución condicional (ifelse())

ventas |>
  mutate(
    tipo_venta = if_else(total > 100, "Alta", "Baja"), # condición simple
    categoria_cliente = case_when(              # múltiples condiciones
      total < 30  ~ "Económico",
      total < 100 ~ "Estándar",
      TRUE        ~ "Premium"               # OJO: convertir a factor
    )
  )
  • Discretizar variables: cut_interval(), cut_number(), cut_width()
  • Funciones para fechas de lubridate: year(), month(), day(), quarter(), week()

Funciones auxiliares de creación de variables

  • rename(): cambiar el nombre de una columna
ventas_renamed <- ventas |>
  rename(
    fecha_venta = fecha,
    monto_total = total
  )
  • across(): aplica la misma transformación a múltiples columnas
ventas |> mutate(across(c(cantidad, subtotal:total), ~ log(.x)))
ventas |> mutate(across(where(is.character), ~ parse_factor(.x)))
  • Operadores aritméticos (+, -, *, /, ^, %/%, %%) y lógicos (<, <=, >, >=, !=)

  • Funciones como log(), lag(), lead(), cumsum(), row_number() etc.

Funciones auxiliares de resumen

  • count(): cuenta los valores únicos de una o más variables
ventas |> count(id_tienda)
# ventas |> group_by(id_tienda) |>  summarize(n = n())
ventas |> count(id_tienda, sort = TRUE)
  • Medidas de centralidad y de dispersión: mean(x), median(x), sd(x), IQR(x)

  • Medidas de rango: min(x), quantile(x, 0.25), max(x)

  • Medidas de posición: first(x), nth(x, 2), last(x).

  • Sumas, productos, etc.

  • Conteos:

    • n(): observaciones totales (tamaño del grupo)

    • n_distinct(x): filas distintas en x

Operaciones Adicionales de Limpieza

  • separate(): dividir una columna por caracter o posición
table3 |> separate(rate, into = c("cases", "population"), sep = "/")
tabla5 <- table3 |> separate(year, into = c("century", "year"), sep = 2)
  • Con convert = TRUE intenta convertir el tipo de dato (no mantener carácter)
table3 |> separate(rate, into = c("cases", "population"), convert = TRUE)
  • unite(): combinar columnas
table5 |> unite(new, century, year, sep = "-")
  • Para nombres de columnas con espacios o caracteres especiales, debemos usar acento invertido

    • Mejor: renombrar sin espacios
datos_problema |> 
  rename(nombre_producto = `Nombre Producto`,
         precio_euros    = `Precio (€)`)

Transformación de Datos: Pivotar

Mismos datos, dos formatos: ancho o largo

Formato ANCHO:

tienda Q1 Q2 Q3 Q4
Madrid 145 158 151 169
Barcelona 152 164 156 175
Valencia 138 151 149 162


ventas_ancho

(datos del informe enviado por gestores)

Formato LARGO:

tienda trimestre ventas
Madrid Q1 145
Madrid Q2 158
Madrid Q3 151
Madrid Q4 169
Barcelona Q1 152
  • Los trimestres son columnas

  • Cada tienda = 1 fila

  • Análisis de datos a menudo complicado

  • Los trimestres son valores

  • Cada tienda-trimestre = 1 fila

  • No adecuado para tablas de presentación final

pivot_longer(): girar de ancho a largo

  • Girar para analizar los datos
ventas_largo <- ventas_ancho |>
  pivot_longer(
    cols = Q1:Q4,              
    names_to = "trimestre",    
    values_to = "ventas"       
  )

  1. tabla a cambiar de forma

  2. nombres o índices (numéricos) de las columnas a girar: representan valores, no variables

  3. nombre para la nueva variable que tendrá, como valores, esas antiguas columnas a girar

  4. nombre para la nueva variable que tendrá como valores las antiguas celdas

pivot_wider(): girar de largo a ancho

  • Girar para crear tabla de presentación
ventas_largo |>             
  pivot_wider(
    names_from = trimestre, 
    values_from = ventas    
  )

  1. tabla a cambiar de forma

  2. nombre de la variable cuyos valores dan nombre a las nuevas columnas

  3. nombre de la variable de cuyas celdas toman los valores las nuevas columnas

Comparación: Tareas Comunes

  • Según nuestro objetivos, podemos preferir formato ancho o largo

  • Problemas prácticos con formato ancho para analizar datos:

  1. Algunas tareas son imposibles. P.e., ¿qué trimestres superan 160 en ventas?
ventas_largo |>
  filter(ventas > 160)
  1. Código repetitivo y propenso a errores. P.e., Calcular crecimiento
ventas_ancho |>
  mutate(
    crec_Q2 = (Q2 - Q1) / Q1 * 100,
    crec_Q3 = (Q3 - Q2) / Q2 * 100,
    crec_Q4 = (Q4 - Q3) / Q3 * 100
  )

ventas_largo |>
  group_by(tienda) |>
  mutate(crecimiento = (ventas - lag(ventas)) / lag(ventas) * 100)

Comparación: Tareas Comunes (cont.)

  1. No escalable. P.e., gráfico temporal por grupos
ggplot(ventas_ancho) +
  geom_line(aes(x = 1:4, y = c(Q1[1], Q2[1], Q3[1], Q4[1])), color = "red") +
  geom_line(aes(x = 1:4, y = c(Q1[2], Q2[2], Q3[2], Q4[2])), color = "blue") +
  geom_line(aes(x = 1:4, y = c(Q1[3], Q2[3], Q3[3], Q4[3])), color = "green")

ggplot(ventas_largo, aes(x = trimestre, y = ventas, 
                         color = tienda, group = tienda)) +
  geom_line() + geom_point()
  • Formato ancho solo para tablas de presentación final
analisis <- ventas_largo |>
  group_by(trimestre) |>
  summarize(total = sum(ingresos))

tabla_presentacion <- ventas_largo |>
  pivot_wider(names_from = trimestre, values_from = ingresos) |>
  mutate(Total = Q1 + Q2 + Q3 + Q4)

Datos Relacionales

Por Qué Múltiples Tablas

Ventajas del Diseño Relacional:

  1. Eficiencia: No repetir información
  2. Consistencia: Un solo lugar para actualizar
  3. Integridad: Relaciones claras entre datos
  4. Escalabilidad: Fácil añadir nuevas tablas
# Ejemplo: información duplicada vs relacional

# MAL: Todo en una tabla (información repetida)
ventas_todo_junto <- ventas %>%
  left_join(productos, by = "id_producto") %>%
  left_join(tiendas, by = "id_tienda") %>%
  left_join(clientes, by = "id_cliente")

# BIEN: Tablas separadas, unir solo cuando necesario
# ventas, productos, tiendas, clientes son tablas independientes

Tipos de Uniones: Visión General

# Datos de ejemplo para ilustrar
df1 <- tibble(id = 1:3, valor_x = c("A", "B", "C"))
df2 <- tibble(id = c(1, 2, 4), valor_y = c("X", "Y", "Z"))

# inner_join: solo coincidencias
inner_join(df1, df2, by = "id")  # resultado: 1, 2

# left_join: todas de la izquierda
left_join(df1, df2, by = "id")   # resultado: 1, 2, 3 (3 con NA)

# right_join: todas de la derecha
right_join(df1, df2, by = "id")  # resultado: 1, 2, 4 (4 con NA)

# full_join: todas de ambas
full_join(df1, df2, by = "id")   # resultado: 1, 2, 3, 4

Caso Real: Enriquecer Datos de Ventas

Objetivo: Análisis completo con información de todas las tablas

# Crear dataset completo para análisis
ventas_completo <- ventas %>%
  # Información de productos
  left_join(productos %>% select(id_producto, nombre_producto, id_categoria, precio, costo),
            by = "id_producto") %>%
  
  # Información de categorías
  left_join(categorias, by = "id_categoria") %>%
  
  # Información de tiendas
  left_join(tiendas %>% select(id_tienda, nombre_tienda, region, tamaño_m2),
            by = "id_tienda") %>%
  
  # Información de clientes
  left_join(clientes %>% select(id_cliente, programa_fidelidad, fecha_registro),
            by = "id_cliente") %>%
  
  # Información de empleados
  left_join(empleados %>% select(id_empleado, puesto),
            by = "id_empleado")

# Ahora podemos hacer análisis complejos
glimpse(ventas_completo)

Análisis Enriquecido:

# Rentabilidad por categoría y región
analisis_rentabilidad <- ventas_completo %>%
  mutate(
    margen_venta = precio - costo,
    rentabilidad = margen_venta * cantidad
  ) %>%
  group_by(nombre_categoria, region) %>%
  summarize(
    num_ventas = n(),
    ingresos = sum(total),
    rentabilidad_total = sum(rentabilidad, na.rm = TRUE),
    margen_porcentaje = round(mean(margen_venta / precio * 100, na.rm = TRUE), 1),
    .groups = "drop"
  ) %>%
  arrange(desc(rentabilidad_total))

head(analisis_rentabilidad, 10)

Caso de Uso: Performance por región y mes

# Primero unir con info de tiendas para obtener región
performance_regional <- ventas %>%
  left_join(tiendas %>% select(id_tienda, region), by = "id_tienda") %>%
  group_by(region, año, mes) %>%
  summarize(
    ventas_totales = n(),
    ingresos = sum(total),
    ticket_promedio = mean(total),
    .groups = "drop"
  ) %>%
  arrange(region, año, mes)

head(performance_regional, 10)

Caso de Uso: Performance por región y mes

# Primero unir con info de tiendas para obtener región
performance_regional <- ventas %>%
  left_join(tiendas %>% select(id_tienda, region), by = "id_tienda") %>%
  group_by(region, año, mes) %>%
  summarize(
    ventas_totales = n(),
    ingresos = sum(total),
    ticket_promedio = mean(total),
    .groups = "drop"
  ) %>%
  arrange(region, año, mes)

head(performance_regional, 10)
  • Aplicación: Crecimiento anual
# Comparación mensual por región
crecimiento_mensual <- performance_regional %>%
  group_by(region, mes) %>%
  arrange(año) %>%
  mutate(
    ingresos_año_anterior = lag(ingresos),
    crecimiento_absoluto = ingresos - ingresos_año_anterior,
    crecimiento_porcentual = round(
      (ingresos - ingresos_año_anterior) / ingresos_año_anterior * 100, 1
    )
  ) %>%
  filter(!is.na(crecimiento_porcentual))

# Regiones con mayor crecimiento
crecimiento_mensual %>%
  filter(año == 2023) %>%
  group_by(region) %>%
  summarize(crecimiento_promedio = mean(crecimiento_porcentual, na.rm = TRUE)) %>%
  arrange(desc(crecimiento_promedio))

Uniones de Filtrado

semi_join: Mantener filas que tienen coincidencia

# Clientes que SÍ han comprado
clientes_activos <- clientes %>%
  semi_join(ventas, by = "id_cliente")

nrow(clientes_activos)  # ¿Cuántos clientes activos?

# Productos que SÍ se han vendido
productos_vendidos <- productos %>%
  semi_join(ventas, by = "id_producto")

anti_join: Mantener filas que NO tienen coincidencia

# Clientes registrados pero sin compras
clientes_sin_compras <- clientes %>%
  anti_join(ventas, by = "id_cliente")

nrow(clientes_sin_compras)  # Oportunidad de marketing

# Productos en catálogo pero nunca vendidos
productos_sin_vender <- productos %>%
  anti_join(ventas, by = "id_producto") %>%
  filter(activo == TRUE)  # y que estén activos

nrow(productos_sin_vender)  # ¿Eliminar del catálogo?

Uniones con Claves Múltiples

# Crear tabla de objetivos mensuales por tienda
objetivos <- tibble(
  id_tienda = rep(1:12, each = 12),
  mes = rep(1:12, times = 12),
  objetivo_ingresos = runif(144, 30000, 80000)
)

# Comparar ventas reales vs objetivos
comparacion_objetivos <- ventas %>%
  group_by(id_tienda, mes) %>%
  summarize(ingresos_reales = sum(total), .groups = "drop") %>%
  left_join(objetivos, by = c("id_tienda", "mes")) %>%
  mutate(
    diferencia = ingresos_reales - objetivo_ingresos,
    cumplimiento_pct = round(ingresos_reales / objetivo_ingresos * 100, 1),
    cumple_objetivo = cumplimiento_pct >= 100
  )

# Tiendas que más superan objetivos
comparacion_objetivos %>%
  filter(cumple_objetivo) %>%
  group_by(id_tienda) %>%
  summarize(
    meses_cumplidos = n(),
    exceso_promedio = mean(diferencia)
  ) %>%
  arrange(desc(meses_cumplidos))

Caso de Estudio Completo: Análisis de Devoluciones

Problema de Negocio

Contexto: Gerente de operaciones preocupado por devoluciones

Preguntas: 1. ¿Qué productos tienen más devoluciones? 2. ¿Hay patrones por tienda o categoría? 3. ¿Impacto financiero de las devoluciones? 4. ¿Recomendaciones accionables?

Paso 1: Exploración Inicial

# Visión general de devoluciones
glimpse(devoluciones)

# Estadísticas básicas
devoluciones %>%
  summarize(
    total_devoluciones = n(),
    tasa_devolucion = n() / nrow(ventas) * 100,
    reembolso_total = sum(reembolso),
    reembolso_promedio = mean(reembolso),
    dias_promedio = mean(as.numeric(fecha_devolucion - 
                        ventas$fecha[match(id_venta, ventas$id_venta)]), na.rm = TRUE)
  )

Paso 2: Análisis por Motivo

# Distribución de motivos
analisis_motivos <- devoluciones %>%
  group_by(motivo) %>%
  summarize(
    num_devoluciones = n(),
    reembolso_total = sum(reembolso),
    .groups = "drop"
  ) %>%
  mutate(
    pct_devoluciones = round(num_devoluciones / sum(num_devoluciones) * 100, 1),
    pct_reembolso = round(reembolso_total / sum(reembolso_total) * 100, 1)
  ) %>%
  arrange(desc(num_devoluciones))

analisis_motivos

# Visualización
ggplot(analisis_motivos, aes(x = reorder(motivo, num_devoluciones), 
                              y = num_devoluciones)) +
  geom_col(fill = "steelblue") +
  coord_flip() +
  labs(title = "Devoluciones por Motivo",
       x = "Motivo", y = "Número de Devoluciones") +
  theme_minimal()

Caso de Estudio Completo (cont.)

Paso 3: Productos Problemáticos

# Unir devoluciones con ventas y productos
productos_devueltos <- devoluciones %>%
  left_join(ventas %>% select(id_venta, id_producto, id_tienda), 
            by = "id_venta") %>%
  left_join(productos %>% select(id_producto, nombre_producto, id_categoria),
            by = "id_producto") %>%
  left_join(categorias %>% select(id_categoria, nombre_categoria),
            by = "id_categoria")

# Top productos con más devoluciones
productos_problema <- productos_devueltos %>%
  group_by(id_producto, nombre_producto, nombre_categoria) %>%
  summarize(
    num_devoluciones = n(),
    reembolso_total = sum(reembolso),
    .groups = "drop"
  ) %>%
  arrange(desc(num_devoluciones)) %>%
  head(20)

productos_problema

# Calcular tasa de devolución por producto
tasa_devolucion_producto <- ventas %>%
  group_by(id_producto) %>%
  summarize(
    veces_vendido = n(),
    .groups = "drop"
  ) %>%
  left_join(
    devoluciones %>%
      left_join(ventas %>% select(id_venta, id_producto), by = "id_venta") %>%
      group_by(id_producto) %>%
      summarize(veces_devuelto = n(), .groups = "drop"),
    by = "id_producto"
  ) %>%
  mutate(
    veces_devuelto = replace_na(veces_devuelto, 0),
    tasa_devolucion = round(veces_devuelto / veces_vendido * 100, 2)
  ) %>%
  filter(veces_vendido >= 10) %>%  # solo productos con suficientes ventas
  arrange(desc(tasa_devolucion))

head(tasa_devolucion_producto, 10)

Paso 4: Análisis por Tienda y Categoría

# Devoluciones por tienda
devoluciones_tienda <- productos_devueltos %>%
  left_join(tiendas %>% select(id_tienda, nombre_tienda, region),
            by = "id_tienda") %>%
  group_by(id_tienda, nombre_tienda, region) %>%
  summarize(
    num_devoluciones = n(),
    reembolso_total = sum(reembolso),
    .groups = "drop"
  )

# Calcular tasa de devolución por tienda
ventas_por_tienda <- ventas %>%
  group_by(id_tienda) %>%
  summarize(num_ventas = n(), .groups = "drop")

comparacion_tiendas <- ventas_por_tienda %>%
  left_join(devoluciones_tienda, by = "id_tienda") %>%
  mutate(
    num_devoluciones = replace_na(num_devoluciones, 0),
    tasa_devolucion = round(num_devoluciones / num_ventas * 100, 2)
  ) %>%
  left_join(tiendas %>% select(id_tienda, nombre_tienda, region),
            by = "id_tienda") %>%
  arrange(desc(tasa_devolucion))

comparacion_tiendas

# Por categoría
devoluciones_categoria <- productos_devueltos %>%
  group_by(nombre_categoria) %>%
  summarize(
    num_devoluciones = n(),
    reembolso_total = sum(reembolso),
    motivo_principal = names(sort(table(motivo), decreasing = TRUE))[1],
    .groups = "drop"
  ) %>%
  arrange(desc(num_devoluciones))

devoluciones_categoria

Caso de Estudio Completo (y 3)

Paso 5: Impacto Financiero

# Impacto total
impacto_financiero <- ventas %>%
  summarize(
    ingresos_brutos = sum(total),
    .groups = "drop"
  ) %>%
  mutate(
    reembolsos = sum(devoluciones$reembolso),
    ingresos_netos = ingresos_brutos - reembolsos,
    tasa_devolucion_financiera = round(reembolsos / ingresos_brutos * 100, 2)
  )

impacto_financiero

# Impacto por período
impacto_temporal <- ventas %>%
  mutate(año_mes = paste(año, sprintf("%02d", mes), sep = "-")) %>%
  group_by(año_mes) %>%
  summarize(
    ingresos_brutos = sum(total),
    .groups = "drop"
  ) %>%
  left_join(
    devoluciones %>%
      left_join(ventas %>% select(id_venta, año, mes), by = "id_venta") %>%
      mutate(año_mes = paste(año, sprintf("%02d", mes), sep = "-")) %>%
      group_by(año_mes) %>%
      summarize(reembolsos = sum(reembolso), .groups = "drop"),
    by = "año_mes"
  ) %>%
  mutate(
    reembolsos = replace_na(reembolsos, 0),
    ingresos_netos = ingresos_brutos - reembolsos,
    tasa_devolucion = round(reembolsos / ingresos_brutos * 100, 2)
  ) %>%
  arrange(año_mes)

# Visualizar tendencia
ggplot(impacto_temporal, aes(x = año_mes, y = tasa_devolucion, group = 1)) +
  geom_line(color = "red", size = 1) +
  geom_point() +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
  labs(title = "Evolución de la Tasa de Devolución",
       x = "Período", y = "Tasa de Devolución (%)")

Caso de Estudio Completo (y 4)

Paso 6: Recomendaciones Accionables

# Crear informe ejecutivo
informe_devoluciones <- list(
  resumen = devoluciones %>%
    summarize(
      total_devoluciones = n(),
      tasa_global = round(n() / nrow(ventas) * 100, 2),
      costo_total = sum(reembolso)
    ),
  
  productos_criticos = tasa_devolucion_producto %>%
    filter(tasa_devolucion > 10) %>%
    head(10),
  
  tiendas_problema = comparacion_tiendas %>%
    filter(tasa_devolucion > 7) %>%
    head(5),
  
  categorias_riesgo = devoluciones_categoria %>%
    head(3)
)

# Mostrar informe
informe_devoluciones

Recomendaciones:

  1. Productos: Revisar calidad de productos con tasa >10%
  2. Tiendas: Capacitación en tiendas con alta tasa de devolución
  3. Categorías: Mejorar descripción de productos en categorías problemáticas
  4. Procesos: Implementar mejor política de cambios para “Talla incorrecta”

Buenas Prácticas y Errores Comunes

Errores Comunes y Soluciones (y 3)

Error 3: No verificar claves duplicadas en joins

# PROBLEMA: claves duplicadas pueden multiplicar filas
df1 <- tibble(id = c(1, 1, 2), valor = c("A", "B", "C"))
df2 <- tibble(id = c(1, 2), info = c("X", "Y"))

resultado <- left_join(df1, df2, by = "id")
nrow(resultado)  # Esperamos 3, tenemos 3 (pero revisar lógica)

# VERIFICAR antes de hacer join
df1 %>% count(id) %>% filter(n > 1)  # ids duplicados
df2 %>% count(id) %>% filter(n > 1)  # ids duplicados

# SOLUCIÓN: decidir qué hacer con duplicados
df1_unico <- df1 %>% distinct(id, .keep_all = TRUE)

Error 4: Orden de operaciones incorrecto

# PROBLEMA: filtrar después de summarize
ventas %>%
  group_by(id_tienda) %>%
  summarize(total = sum(total)) %>%
  filter(cantidad > 3)  # ERROR: cantidad ya no existe

# SOLUCIÓN: filtrar antes de summarize
ventas %>%
  filter(cantidad > 3) %>%
  group_by(id_tienda) %>%
  summarize(total = sum(total))

Optimización

# USO EFICIENTE: filtrar primero, luego unir
ventas_2023 <- ventas %>%
  filter(año == 2023) %>%  # reduce tamaño
  left_join(productos, by = "id_producto")

# USO INEFICIENTE: unir todo, luego filtrar
ventas_2023_lento <- ventas %>%
  left_join(productos, by = "id_producto") %>%
  filter(año == 2023)

# SELECCIONAR SOLO COLUMNAS NECESARIAS
productos_minimo <- productos %>%
  select(id_producto, nombre_producto, precio)

ventas_join <- ventas %>%
  left_join(productos_minimo, by = "id_producto")

Gráficos de Análisis Exploratorio

# Distribución de ventas
ggplot(ventas, aes(x = total)) +
  geom_histogram(bins = 50, fill = "steelblue", alpha = 0.7) +
  labs(title = "Distribución de Ventas",
       x = "Importe Total (€)", y = "Frecuencia") +
  theme_minimal()

# Ventas por día de la semana
ventas_dia <- ventas %>%
  group_by(dia_semana) %>%
  summarize(
    num_ventas = n(),
    ingresos = sum(total),
    ticket_promedio = mean(total)
  )

ggplot(ventas_dia, aes(x = dia_semana, y = ingresos)) +
  geom_col(fill = "coral") +
  labs(title = "Ingresos por Día de la Semana",
       x = "Día", y = "Ingresos (€)") +
  theme_minimal()

# Evolución temporal
ventas_mensual <- ventas %>%
  group_by(año, mes) %>%
  summarize(ingresos = sum(total), .groups = "drop") %>%
  mutate(periodo = paste(año, sprintf("%02d", mes), sep = "-"))

ggplot(ventas_mensual, aes(x = periodo, y = ingresos, group = 1)) +
  geom_line(color = "darkgreen", size = 1) +
  geom_point() +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
  labs(title = "Evolución de Ingresos Mensuales",
       x = "Período", y = "Ingresos (€)")

Comparaciones y Rankings

# Top 10 tiendas por ingresos
top_tiendas <- ventas %>%
  left_join(tiendas %>% select(id_tienda, nombre_tienda), by = "id_tienda") %>%
  group_by(nombre_tienda) %>%
  summarize(ingresos = sum(total), .groups = "drop") %>%
  arrange(desc(ingresos)) %>%
  head(10)

ggplot(top_tiendas, aes(x = reorder(nombre_tienda, ingresos), y = ingresos)) +
  geom_col(fill = "steelblue") +
  coord_flip() +
  labs(title = "Top 10 Tiendas por Ingresos",
       x = "Tienda", y = "Ingresos (€)") +
  theme_minimal()

# Comparación entre regiones
ventas_region <- ventas %>%
  left_join(tiendas %>% select(id_tienda, region), by = "id_tienda") %>%
  group_by(region, año) %>%
  summarize(ingresos = sum(total), .groups = "drop")

ggplot(ventas_region, aes(x = factor(año), y = ingresos, fill = region)) +
  geom_col(position = "dodge") +
  labs(title = "Ingresos por Región y Año",
       x = "Año", y = "Ingresos (€)", fill = "Región") +
  theme_minimal()

Principios Clave

  • Datos Ordenados

  • Código Legible:

    • Usar pipe %>% para encadenar operaciones

    • Un paso por línea

    • Nombres descriptivos de variables

    • Comentarios para lógica compleja

  • Eficiencia:

    • Filtrar temprano, unir tarde

    • Seleccionar solo columnas necesarias

    • Verificar claves antes de joins

  • Inspeccionar resultados intermedios

  • Reproducibilidad:

    • Scripts ordenados y comentados

    • Guardar datos procesados

    • Documentar decisiones de limpieza

Ejercicios de Práctica

Ejercicio 1: Análisis de Categorías

Usando los datos de RetailCorp:

  1. Identifica las 3 categorías con mayores ingresos en 2023
  2. Calcula la tasa de crecimiento año sobre año para cada categoría
  3. Determina qué categorías tienen los tickets promedio más altos
  4. Crea un gráfico comparativo

Ejercicio 2: Segmentación de Clientes

  1. Clasifica clientes según su valor total de compras (Bajo/Medio/Alto/Premium)
  2. Calcula la recencia (días desde última compra)
  3. Identifica clientes en riesgo (no han comprado en >90 días)
  4. Propón estrategia de retención

Ejercicio 3: Análisis de Empleados

  1. Calcula el promedio de ventas por empleado
  2. Identifica empleados top performers
  3. Analiza si hay diferencias por puesto
  4. Sugiere un sistema de bonificaciones

Ejercicio 4: Optimización de Inventario

  1. Lista productos con baja rotación (pocas ventas)
  2. Identifica productos descontinuados
  3. Calcula el stock óptimo por tienda (simulado)
  4. Recomienda productos a eliminar del catálogo